@hypothesi/tauri-mcp-server 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,39 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Fireside Development, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Apache License, Version 2.0
26
+
27
+ Copyright (c) 2025 Fireside Development, LLC
28
+
29
+ Licensed under the Apache License, Version 2.0 (the "License");
30
+ you may not use this file except in compliance with the License.
31
+ You may obtain a copy of the License at
32
+
33
+ http://www.apache.org/licenses/LICENSE-2.0
34
+
35
+ Unless required by applicable law or agreed to in writing, software
36
+ distributed under the License is distributed on an "AS IS" BASIS,
37
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
38
+ See the License for the specific language governing permissions and
39
+ limitations under the License.
package/dist/config.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Configuration for the MCP Bridge connection.
3
+ *
4
+ * This module provides configuration options for connecting to Tauri apps,
5
+ * with support for environment variables and sensible defaults.
6
+ */
7
+ /**
8
+ * Gets the default host for MCP Bridge connections.
9
+ *
10
+ * Resolution priority:
11
+ * 1. MCP_BRIDGE_HOST environment variable
12
+ * 2. TAURI_DEV_HOST environment variable (set by Tauri CLI for mobile dev)
13
+ * 3. 'localhost' (default)
14
+ */
15
+ export function getDefaultHost() {
16
+ // eslint-disable-next-line no-process-env
17
+ return process.env.MCP_BRIDGE_HOST || process.env.TAURI_DEV_HOST || 'localhost';
18
+ }
19
+ /**
20
+ * Gets the default port for MCP Bridge connections.
21
+ *
22
+ * Resolution priority:
23
+ * 1. MCP_BRIDGE_PORT environment variable
24
+ * 2. 9223 (default)
25
+ */
26
+ export function getDefaultPort() {
27
+ // eslint-disable-next-line no-process-env
28
+ const port = process.env.MCP_BRIDGE_PORT;
29
+ return port ? parseInt(port, 10) : 9223;
30
+ }
31
+ /**
32
+ * Gets the full bridge configuration from environment variables.
33
+ */
34
+ export function getConfig() {
35
+ return {
36
+ host: getDefaultHost(),
37
+ port: getDefaultPort(),
38
+ };
39
+ }
40
+ /**
41
+ * Builds a WebSocket URL from host and port.
42
+ */
43
+ export function buildWebSocketURL(host, port) {
44
+ return `ws://${host}:${port}`;
45
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * App discovery and session management for multiple Tauri instances.
3
+ *
4
+ * This module handles discovering and connecting to multiple Tauri apps
5
+ * running with MCP Bridge on the same machine or remote devices using port scanning.
6
+ */
7
+ import { getDefaultHost, getDefaultPort } from '../config.js';
8
+ import { PluginClient } from './plugin-client.js';
9
+ /**
10
+ * Manages discovery and connection to multiple Tauri app instances
11
+ */
12
+ export class AppDiscovery {
13
+ _activeSessions = new Map();
14
+ _host;
15
+ _basePort;
16
+ _maxPorts = 100;
17
+ constructor(host, basePort) {
18
+ this._host = host ?? getDefaultHost();
19
+ this._basePort = basePort ?? getDefaultPort();
20
+ }
21
+ /**
22
+ * Gets the configured host.
23
+ */
24
+ get host() {
25
+ return this._host;
26
+ }
27
+ /**
28
+ * Sets the host for discovery.
29
+ */
30
+ setHost(host) {
31
+ this._host = host;
32
+ }
33
+ /**
34
+ * Discovers available Tauri app instances by scanning ports
35
+ */
36
+ async discoverApps() {
37
+ const apps = [];
38
+ // Scan port range for available apps
39
+ for (let offset = 0; offset < this._maxPorts; offset++) {
40
+ const port = this._basePort + offset;
41
+ if (await this._isPortInUse(port)) {
42
+ apps.push({ host: this._host, port, available: true });
43
+ }
44
+ }
45
+ return apps;
46
+ }
47
+ /**
48
+ * Connects to a specific app on a host and port
49
+ */
50
+ async connectToPort(port, appName, host) {
51
+ const targetHost = host ?? this._host;
52
+ const sessionId = `${targetHost}_${port}`;
53
+ // Check if already connected
54
+ const existing = this._activeSessions.get(sessionId);
55
+ if (existing?.connected) {
56
+ return existing;
57
+ }
58
+ const client = new PluginClient(targetHost, port);
59
+ try {
60
+ await client.connect();
61
+ const session = {
62
+ appId: sessionId,
63
+ name: appName || `Tauri App (${targetHost}:${port})`,
64
+ host: targetHost,
65
+ port,
66
+ client,
67
+ connected: true,
68
+ };
69
+ this._activeSessions.set(sessionId, session);
70
+ return session;
71
+ }
72
+ catch (error) {
73
+ throw new Error(`Failed to connect to ${targetHost}:${port}: ${error}`);
74
+ }
75
+ }
76
+ /**
77
+ * Gets the first available app
78
+ */
79
+ async getFirstAvailableApp() {
80
+ const apps = await this.discoverApps();
81
+ return apps.length > 0 ? apps[0] : null;
82
+ }
83
+ /**
84
+ * Disconnects from a specific session
85
+ */
86
+ async disconnectSession(sessionId) {
87
+ const session = this._activeSessions.get(sessionId);
88
+ if (session?.client) {
89
+ await session.client.disconnect();
90
+ this._activeSessions.delete(sessionId);
91
+ }
92
+ }
93
+ /**
94
+ * Disconnects from all apps
95
+ */
96
+ async disconnectAll() {
97
+ for (const [, session] of this._activeSessions) {
98
+ if (session.client) {
99
+ await session.client.disconnect();
100
+ }
101
+ }
102
+ this._activeSessions.clear();
103
+ }
104
+ /**
105
+ * Gets the active session by ID
106
+ */
107
+ getSession(sessionId) {
108
+ return this._activeSessions.get(sessionId);
109
+ }
110
+ /**
111
+ * Gets all active sessions
112
+ */
113
+ getAllSessions() {
114
+ return Array.from(this._activeSessions.values());
115
+ }
116
+ /**
117
+ * Try to connect to the default port
118
+ */
119
+ async connectToDefaultPort() {
120
+ return this.connectToPort(this._basePort, 'Default Tauri App');
121
+ }
122
+ /**
123
+ * Check if a port is in use (likely a Tauri app)
124
+ */
125
+ async _isPortInUse(port) {
126
+ const client = new PluginClient(this._host, port);
127
+ try {
128
+ // Try to connect briefly to see if port responds
129
+ await Promise.race([
130
+ client.connect(),
131
+ new Promise((_, reject) => {
132
+ setTimeout(() => { reject(new Error('Timeout')); }, 100);
133
+ }),
134
+ ]);
135
+ // Connection succeeded - clean up and return true
136
+ client.disconnect();
137
+ return true;
138
+ }
139
+ catch {
140
+ // Connection failed or timed out - always clean up the client
141
+ // This prevents orphaned WebSocket connections from emitting errors later
142
+ client.disconnect();
143
+ return false;
144
+ }
145
+ }
146
+ }
147
+ // Singleton instance
148
+ export const appDiscovery = new AppDiscovery();
@@ -0,0 +1,208 @@
1
+ import WebSocket from 'ws';
2
+ import { EventEmitter } from 'events';
3
+ import { buildWebSocketURL, getDefaultHost, getDefaultPort } from '../config.js';
4
+ /**
5
+ * Client to communicate with the MCP Bridge plugin's WebSocket server
6
+ */
7
+ /* eslint-disable no-plusplus */
8
+ export class PluginClient extends EventEmitter {
9
+ _ws = null;
10
+ _url;
11
+ _host;
12
+ _port;
13
+ _reconnectAttempts = 0;
14
+ _shouldReconnect = true; // Keep trying forever until explicitly disconnected
15
+ _reconnectDelay = 1000; // Start with 1s, max 30s
16
+ _pendingRequests = new Map();
17
+ /**
18
+ * Constructor for PluginClient
19
+ * @param host Host address of the WebSocket server
20
+ * @param port Port number of the WebSocket server
21
+ */
22
+ constructor(host, port) {
23
+ super();
24
+ this._host = host;
25
+ this._port = port;
26
+ this._url = buildWebSocketURL(host, port);
27
+ // CRITICAL: Attach a default error handler to prevent crashes.
28
+ // In Node.js, if an EventEmitter emits 'error' with no listeners, it throws
29
+ // an uncaught exception that crashes the process. This is especially important
30
+ // during port scanning where connections may fail after the caller has moved on.
31
+ this.on('error', () => {
32
+ // Silently ignore - errors are also returned via promise rejections
33
+ });
34
+ }
35
+ /**
36
+ * Creates a PluginClient with default configuration from environment.
37
+ */
38
+ static create_default() {
39
+ return new PluginClient(getDefaultHost(), getDefaultPort());
40
+ }
41
+ /**
42
+ * Gets the host this client is configured to connect to.
43
+ */
44
+ get host() {
45
+ return this._host;
46
+ }
47
+ /**
48
+ * Gets the port this client is configured to connect to.
49
+ */
50
+ get port() {
51
+ return this._port;
52
+ }
53
+ /**
54
+ * Connect to the plugin's WebSocket server
55
+ */
56
+ async connect() {
57
+ return new Promise((resolve, reject) => {
58
+ if (this._ws?.readyState === WebSocket.OPEN) {
59
+ resolve();
60
+ return;
61
+ }
62
+ this._ws = new WebSocket(this._url);
63
+ this._ws.on('open', () => {
64
+ // Connected to MCP Bridge plugin
65
+ this._reconnectAttempts = 0;
66
+ this.emit('connected');
67
+ resolve();
68
+ });
69
+ this._ws.on('message', (data) => {
70
+ try {
71
+ const message = JSON.parse(data.toString());
72
+ // Check if this is a response to a pending request
73
+ if (message.id && this._pendingRequests.has(message.id)) {
74
+ const pending = this._pendingRequests.get(message.id);
75
+ if (pending) {
76
+ clearTimeout(pending.timeout);
77
+ this._pendingRequests.delete(message.id);
78
+ pending.resolve(message);
79
+ }
80
+ }
81
+ else {
82
+ // It's a broadcast event
83
+ this.emit('event', message);
84
+ }
85
+ }
86
+ catch (e) {
87
+ // Failed to parse WebSocket message
88
+ }
89
+ });
90
+ this._ws.on('error', (err) => {
91
+ // WebSocket error - emit for any listeners, then reject the promise.
92
+ // Note: The constructor attaches a default error handler to prevent crashes.
93
+ this.emit('error', err);
94
+ reject(err);
95
+ });
96
+ this._ws.on('close', () => {
97
+ // Disconnected from MCP Bridge plugin
98
+ this.emit('disconnected');
99
+ this._ws = null;
100
+ // Reject all pending requests since the connection is gone
101
+ for (const [id, pending] of this._pendingRequests) {
102
+ clearTimeout(pending.timeout);
103
+ pending.reject(new Error('Connection closed'));
104
+ this._pendingRequests.delete(id);
105
+ }
106
+ // Auto-reconnect with exponential backoff (max 30s)
107
+ if (this._shouldReconnect) {
108
+ this._reconnectAttempts++;
109
+ const delay = Math.min(this._reconnectDelay * this._reconnectAttempts, 30000);
110
+ setTimeout(() => {
111
+ this.connect().catch(() => {
112
+ // Reconnection failed - will retry on next close event
113
+ });
114
+ }, delay);
115
+ }
116
+ });
117
+ });
118
+ }
119
+ /**
120
+ * Disconnect from the plugin
121
+ */
122
+ disconnect() {
123
+ this._shouldReconnect = false; // Prevent auto-reconnect
124
+ if (this._ws) {
125
+ this._ws.close();
126
+ this._ws = null;
127
+ }
128
+ }
129
+ /**
130
+ * Send a command to the plugin and wait for response
131
+ */
132
+ async sendCommand(command, timeoutMs = 5000) {
133
+ // If not connected, try to reconnect first
134
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
135
+ try {
136
+ await this.connect();
137
+ }
138
+ catch {
139
+ throw new Error('Not connected to plugin and reconnection failed');
140
+ }
141
+ }
142
+ // Double-check connection after reconnect attempt
143
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
144
+ throw new Error('Not connected to plugin');
145
+ }
146
+ // Generate unique ID for this request
147
+ const id = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
148
+ const commandWithId = { ...command, id };
149
+ return new Promise((resolve, reject) => {
150
+ // Set up timeout
151
+ const timeout = setTimeout(() => {
152
+ this._pendingRequests.delete(id);
153
+ reject(new Error(`Request timeout after ${timeoutMs}ms`));
154
+ }, timeoutMs);
155
+ // Store pending request
156
+ this._pendingRequests.set(id, { resolve, reject, timeout });
157
+ // Send command
158
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
159
+ this._ws.send(JSON.stringify(commandWithId), (error) => {
160
+ if (error) {
161
+ clearTimeout(timeout);
162
+ this._pendingRequests.delete(id);
163
+ reject(error);
164
+ }
165
+ });
166
+ });
167
+ }
168
+ /**
169
+ * Check if connected
170
+ */
171
+ isConnected() {
172
+ return this._ws?.readyState === WebSocket.OPEN;
173
+ }
174
+ }
175
+ // Singleton instance
176
+ let pluginClient = null;
177
+ /**
178
+ * Gets or creates a singleton PluginClient.
179
+ * @param host Optional host override
180
+ * @param port Optional port override
181
+ */
182
+ export function getPluginClient(host, port) {
183
+ const resolvedHost = host ?? getDefaultHost();
184
+ const resolvedPort = port ?? getDefaultPort();
185
+ if (!pluginClient) {
186
+ pluginClient = new PluginClient(resolvedHost, resolvedPort);
187
+ }
188
+ return pluginClient;
189
+ }
190
+ /**
191
+ * Resets the singleton client (useful for reconnecting with different config).
192
+ */
193
+ export function resetPluginClient() {
194
+ if (pluginClient) {
195
+ pluginClient.disconnect();
196
+ pluginClient = null;
197
+ }
198
+ }
199
+ export async function connectPlugin(host, port) {
200
+ const client = getPluginClient(host, port);
201
+ if (!client.isConnected()) {
202
+ await client.connect();
203
+ }
204
+ }
205
+ export async function disconnectPlugin() {
206
+ const client = getPluginClient();
207
+ client.disconnect();
208
+ }
@@ -0,0 +1,142 @@
1
+ import { z } from 'zod';
2
+ import { getPluginClient, connectPlugin } from './plugin-client.js';
3
+ export const ExecuteIPCCommandSchema = z.object({
4
+ command: z.string(),
5
+ args: z.unknown().optional(),
6
+ });
7
+ export async function executeIPCCommand(command, args = {}) {
8
+ try {
9
+ // Ensure we're connected to the plugin
10
+ await connectPlugin();
11
+ const client = getPluginClient();
12
+ // Send IPC command via WebSocket to the mcp-bridge plugin
13
+ const response = await client.sendCommand({
14
+ command: 'invoke_tauri',
15
+ args: { command, args },
16
+ });
17
+ if (!response.success) {
18
+ return JSON.stringify({ success: false, error: response.error || 'Unknown error' });
19
+ }
20
+ return JSON.stringify({ success: true, result: response.data });
21
+ }
22
+ catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return JSON.stringify({ success: false, error: message });
25
+ }
26
+ }
27
+ export const GetWindowInfoSchema = z.object({});
28
+ export async function getWindowInfo() {
29
+ try {
30
+ const result = await executeIPCCommand('plugin:mcp-bridge|get_window_info');
31
+ const parsed = JSON.parse(result);
32
+ if (!parsed.success) {
33
+ throw new Error(parsed.error || 'Unknown error');
34
+ }
35
+ return JSON.stringify(parsed.result);
36
+ }
37
+ catch (error) {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ throw new Error(`Failed to get window info: ${message}`);
40
+ }
41
+ }
42
+ // Combined schema for managing IPC monitoring
43
+ export const ManageIPCMonitoringSchema = z.object({
44
+ action: z.enum(['start', 'stop']).describe('Action to perform: start or stop IPC monitoring'),
45
+ });
46
+ // Keep individual schemas for backward compatibility if needed
47
+ export const StartIPCMonitoringSchema = z.object({});
48
+ export const StopIPCMonitoringSchema = z.object({});
49
+ export async function manageIPCMonitoring(action) {
50
+ if (action === 'start') {
51
+ return startIPCMonitoring();
52
+ }
53
+ return stopIPCMonitoring();
54
+ }
55
+ export async function startIPCMonitoring() {
56
+ try {
57
+ const result = await executeIPCCommand('plugin:mcp-bridge|start_ipc_monitor');
58
+ const parsed = JSON.parse(result);
59
+ if (!parsed.success) {
60
+ throw new Error(parsed.error || 'Unknown error');
61
+ }
62
+ return JSON.stringify(parsed.result);
63
+ }
64
+ catch (error) {
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ throw new Error(`Failed to start IPC monitoring: ${message}`);
67
+ }
68
+ }
69
+ export async function stopIPCMonitoring() {
70
+ try {
71
+ const result = await executeIPCCommand('plugin:mcp-bridge|stop_ipc_monitor');
72
+ const parsed = JSON.parse(result);
73
+ if (!parsed.success) {
74
+ throw new Error(parsed.error || 'Unknown error');
75
+ }
76
+ return JSON.stringify(parsed.result);
77
+ }
78
+ catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ throw new Error(`Failed to stop IPC monitoring: ${message}`);
81
+ }
82
+ }
83
+ export const GetIPCEventsSchema = z.object({
84
+ filter: z.string().optional().describe('Filter events by command name'),
85
+ });
86
+ export async function getIPCEvents(filter) {
87
+ try {
88
+ const result = await executeIPCCommand('plugin:mcp-bridge|get_ipc_events');
89
+ const parsed = JSON.parse(result);
90
+ if (!parsed.success) {
91
+ throw new Error(parsed.error || 'Unknown error');
92
+ }
93
+ let events = parsed.result;
94
+ if (filter && Array.isArray(events)) {
95
+ events = events.filter((e) => {
96
+ const event = e;
97
+ return event.command && event.command.includes(filter);
98
+ });
99
+ }
100
+ return JSON.stringify(events);
101
+ }
102
+ catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ throw new Error(`Failed to get IPC events: ${message}`);
105
+ }
106
+ }
107
+ export const EmitTestEventSchema = z.object({
108
+ eventName: z.string(),
109
+ payload: z.unknown(),
110
+ });
111
+ export async function emitTestEvent(eventName, payload) {
112
+ try {
113
+ const result = await executeIPCCommand('plugin:mcp-bridge|emit_event', {
114
+ eventName,
115
+ payload,
116
+ });
117
+ const parsed = JSON.parse(result);
118
+ if (!parsed.success) {
119
+ throw new Error(parsed.error || 'Unknown error');
120
+ }
121
+ return JSON.stringify(parsed.result);
122
+ }
123
+ catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ throw new Error(`Failed to emit event: ${message}`);
126
+ }
127
+ }
128
+ export const GetBackendStateSchema = z.object({});
129
+ export async function getBackendState() {
130
+ try {
131
+ const result = await executeIPCCommand('plugin:mcp-bridge|get_backend_state');
132
+ const parsed = JSON.parse(result);
133
+ if (!parsed.success) {
134
+ throw new Error(parsed.error || 'Unknown error');
135
+ }
136
+ return JSON.stringify(parsed.result);
137
+ }
138
+ catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ throw new Error(`Failed to get backend state: ${message}`);
141
+ }
142
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * WebSocket protocol types for communication between MCP server and Tauri plugin
3
+ *
4
+ * This file defines the message format for the WebSocket-based communication
5
+ * between the Node.js MCP server and the Rust Tauri plugin.
6
+ */
7
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Find an element using various selector strategies
3
+ *
4
+ * @param {Object} params
5
+ * @param {string} params.selector - Element selector
6
+ * @param {string} params.strategy - Selector strategy: 'css', 'xpath', or 'text'
7
+ */
8
+ (function(params) {
9
+ const { selector, strategy } = params;
10
+ let element;
11
+
12
+ if (strategy === 'text') {
13
+ // Find element containing text
14
+ const xpath = "//*[contains(text(), '" + selector + "')]";
15
+ const result = document.evaluate(
16
+ xpath,
17
+ document,
18
+ null,
19
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
20
+ null
21
+ );
22
+ element = result.singleNodeValue;
23
+ } else if (strategy === 'xpath') {
24
+ // XPath selector
25
+ const result = document.evaluate(
26
+ selector,
27
+ document,
28
+ null,
29
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
30
+ null
31
+ );
32
+ element = result.singleNodeValue;
33
+ } else {
34
+ // CSS selector (default)
35
+ element = document.querySelector(selector);
36
+ }
37
+
38
+ if (element) {
39
+ const outerHTML = element.outerHTML;
40
+ // Truncate long HTML to avoid overwhelming output
41
+ const truncated = outerHTML.length > 200
42
+ ? outerHTML.substring(0, 200) + '...'
43
+ : outerHTML;
44
+ return 'Found element: ' + truncated;
45
+ }
46
+
47
+ return 'Element not found';
48
+ })
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Focus an element
3
+ *
4
+ * @param {Object} params
5
+ * @param {string} params.selector - CSS selector for element to focus
6
+ */
7
+ (function(params) {
8
+ const { selector } = params;
9
+
10
+ const element = document.querySelector(selector);
11
+ if (!element) {
12
+ throw new Error(`Element not found: ${selector}`);
13
+ }
14
+
15
+ element.focus();
16
+ return `Focused element: ${selector}`;
17
+ })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Get computed CSS styles for elements
3
+ *
4
+ * @param {Object} params
5
+ * @param {string} params.selector - CSS selector for element(s)
6
+ * @param {string[]} params.properties - Specific CSS properties to retrieve
7
+ * @param {boolean} params.multiple - Whether to get styles for all matching elements
8
+ */
9
+ (function(params) {
10
+ const { selector, properties, multiple } = params;
11
+
12
+ const elements = multiple
13
+ ? Array.from(document.querySelectorAll(selector))
14
+ : [document.querySelector(selector)];
15
+
16
+ if (!elements[0]) {
17
+ throw new Error(`Element not found: ${selector}`);
18
+ }
19
+
20
+ const results = elements.map(element => {
21
+ const styles = window.getComputedStyle(element);
22
+
23
+ if (properties.length > 0) {
24
+ const result = {};
25
+ properties.forEach(prop => {
26
+ result[prop] = styles.getPropertyValue(prop);
27
+ });
28
+ return result;
29
+ }
30
+
31
+ // Return all styles
32
+ const allStyles = {};
33
+ for (let i = 0; i < styles.length; i++) {
34
+ const prop = styles[i];
35
+ allStyles[prop] = styles.getPropertyValue(prop);
36
+ }
37
+ return allStyles;
38
+ });
39
+
40
+ return JSON.stringify(multiple ? results : results[0]);
41
+ })